Skip to content

fix(deploy): prevent code-generation injection from angular.json values - #3739

Open
herdiyana256 wants to merge 1 commit into
angular:mainfrom
herdiyana256:fix/deploy-codegen-injection
Open

fix(deploy): prevent code-generation injection from angular.json values#3739
herdiyana256 wants to merge 1 commit into
angular:mainfrom
herdiyana256:fix/deploy-codegen-injection

Conversation

@herdiyana256

Copy link
Copy Markdown

The SSR deploy builders interpolate several angular.json-derived values straight into generated artifacts that are later executed.

A server build target's outputPath (read via getTargetOptions) is written raw into the generated Cloud Function index.js as require('./${path}/main') and into the generated package.json start script as node ${path}/main.js. functionsNodeVersion is written raw into the generated Cloud Run Dockerfile as FROM node:${version}-slim. None of these has any validation. A crafted server outputPath such as x').app(); require('child_process').execSync('...'); (' lands as a standalone statement in index.js and runs on every Cloud Function cold start (and locally during firebase serve preview); a crafted functionsNodeVersion injects extra RUN instructions executed during the Cloud Run container build. Reachable the moment a developer runs ng deploy on a malicious or cloned workspace. These are distinct sinks from the gcloud argv path and the execSync calls addressed separately.

The fix validates each build target's outputPath (assertSafeOutputPath) and functionsNodeVersion (assertSafeNodeVersion) before they reach code generation, rejecting values that carry quotes, newlines, or shell metacharacters, and adds a functionsNodeVersion schema pattern. Unit tests cover both validators.

npm run test:node passes (150 specs, 0 failures); lint and typecheck clean.

@armando-navarro armando-navarro added bump: patch comp: schematics ng add / deploy schematics (src/schematics). type: bug Defect: expected behavior doesn't happen. labels Aug 11, 2026

@armando-navarro armando-navarro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, and for keeping at the ng deploy hardening. I reproduced what you describe: rendering the generated function with a crafted server outputPath gives a standalone require('child_process').execSync(...) statement in index.js, and a crafted functionsNodeVersion adds its own RUN line to the Dockerfile.

There is one gap I think should be filled before this merges, and a few smaller notes that should not hold it up.

Blocking: two more angular.json values still reach the generated function unguarded

functionName and region come from the same deploy options block as functionsNodeVersion, and both are written straight into the same generated index.js with no validation:

  • functionName is written as a bare identifier:
    • exports.${functionName || DEFAULT_FUNCTION_NAME} sits in functions-templates.ts at lines 44 and 62, so this applies to both the default and the CF3v2 template.
    • A value of ssr; require('child_process').execSync('...'); var _x renders as exports.ssr; followed by the injected call, with the template's own = assignment becoming that variable's initializer.
    • The file still parses, so the injected call runs when the function loads.
  • region is written inside a quoted string:
    • .region('${options.region || DEFAULT_FUNCTION_REGION}') at line 45 puts it in a single-quoted literal in the default template, so a ' breaks out exactly the way outputPath did.
    • The CF3v2 template passes region through JSON.stringify, so that path is already safe.

Neither has a pattern in schema.json (functionName and region are both plain type: string), so nothing upstream constrains them either.

Since the description says this prevents code-generation injection from angular.json values, either of these would unblock it for me:

  • Extend the fix with a check on functionName and one on region, called before the template runs, matching the pattern you already established. This is the outcome I would prefer.
  • Narrow the title and description to the two values this covers, and open a short follow-up issue for functionName and region, so the change matches its claim and the remaining exposure stays tracked rather than closed over.

Non-blocking notes

  • Consider escaping where the value is written rather than only screening it on the way in. Your validators are a blocklist of dangerous characters, which has to stay ahead of every context the value lands in. Two places that could be structural instead:
    • For a string position, emit ${JSON.stringify(value)} and drop the quotes already in the template, since JSON.stringify supplies its own. Written as .region(${JSON.stringify(...)}) it escapes correctly, whereas leaving the existing quotes in place would yield .region('"us-central1"') and change the value.
    • For the exports.<name> position, an allowlist of valid JavaScript identifiers is easier to reason about than a list of rejects. It would also make a currently silent failure loud: a functionName containing a dash already generates a file that does not parse.
  • A leading dash still gets through.
    • The character list does not reject -, and on the Cloud Run path the generated package.json sets start: node <serverOutputPath>/main.js.
    • So a server outputPath beginning with - reaches node as a flag rather than a path.
    • The comment above the check says a legitimate output directory never contains these characters, which reads stronger than what the character class enforces.
  • Nothing fails if the checks stop being called.
    • The new specs exercise assertSafeOutputPath and assertSafeNodeVersion directly.
    • What I could not find is a test that fails if the builder stops calling them: removing the calls from deployToFunction and deployToCloudRun still passes the whole suite.
    • A test that drives one of those functions with a hostile outputPath and expects a throw would keep the protection from quietly disappearing later.
  • Spec count in the description.
    • Locally npm run test:node reports 78 specs on this branch, not the 150 in the body.
    • The branch is based on an older commit, so a rebase on current main would refresh that number.

If I have misread any of this, point me at it and I will take another look.

deployToFunction / deployToCloudRun interpolate several angular.json-derived
values straight into generated, later-executed artifacts. A server build
target's outputPath is written raw into the generated Cloud Function index.js
(`require('./<outputPath>/main')`) and into the generated package.json start
script (`node <outputPath>/main.js`); functionName is written raw as the
`exports.<name>` target in index.js; region is written into a quoted string in
the default index.js template; and functionsNodeVersion is written raw into the
generated Cloud Run Dockerfile (`FROM node:<version>-slim`). A malicious or
cloned workspace could therefore run arbitrary code in the deployed
function/container (and locally during `firebase serve` preview) via
`ng deploy`. These sinks are distinct from the gcloud argv path (PR angular#3726) and
the execSync sinks (PR angular#3738).

Validate outputPath (assertSafeOutputPath, now also rejecting a leading dash
that node would read as a flag in the start script), functionName
(assertSafeFunctionName, a plain-identifier allowlist), and functionsNodeVersion
(assertSafeNodeVersion) before they reach code generation, and emit region
through JSON.stringify in the default template so it is structurally escaped
rather than screened. Add schema patterns for functionName, region and
functionsNodeVersion. Tests cover the validators directly and drive
deployToFunction / deployToCloudRun with hostile inputs so the checks cannot be
dropped without a failing spec.
@herdiyana256
herdiyana256 force-pushed the fix/deploy-codegen-injection branch from 6333899 to 4abf2eb Compare August 12, 2026 12:17
@herdiyana256

Copy link
Copy Markdown
Author

Thanks for the careful review, and for reproducing both sinks. I took the outcome you preferred and closed the functionName / region gap in this PR rather than deferring it, and folded in the smaller notes too. Pushed as a single amended commit.

Blocking: functionName and region

  • functionName: added assertSafeFunctionName, called in deployToFunction before either template runs, so it guards both the default and the CF3v2 exports.<name> positions. It uses a plain-identifier allowlist (^[A-Za-z_$][A-Za-z0-9_$]*$) rather than a blocklist, per your note that an allowlist is easier to reason about here. That also makes the dash case loud: a name like my-fn now throws instead of silently generating a file that does not parse.
  • region: went structural. The default template now emits .region(${JSON.stringify(options.region || DEFAULT_FUNCTION_REGION)}) with the surrounding quotes dropped, so it escapes itself the same way the CF3v2 path already did through JSON.stringify. No separate screen needed for that value.

Non-blocking notes

  • Escape vs screen: adopted structurally where the context is single (JSON.stringify for region, identifier allowlist for functionName). outputPath still goes through a screen because it lands in three different contexts in the same run (a JS string literal, the node <path>/main.js start script, and join(workspaceRoot, ...)), so there is no one encoding that fits all of them.
  • Leading dash: fixed. assertSafeOutputPath now also rejects a value beginning with -, so a server outputPath cannot reach the start script as a node flag.
  • Comment accuracy: reworded the comment above assertSafeOutputPath so it states what the character class and the dash check actually enforce, instead of the broader claim.
  • A test that fails if the calls disappear: added a deploy codegen hardening is wired into the builders block that drives deployToFunction and deployToCloudRun with hostile outputPath, functionName, and functionsNodeVersion, and asserts they reject. I confirmed the intent by deleting the four call sites: those specs go red (5 failures), and pass again once restored. The region spec renders the function with a hostile region and checks it comes back JSON-escaped and still compiles, so a regression there is caught structurally.
  • Spec count: rebased on current main. The count in the original description was stale; the suite is now 170 specs, 0 failures.

Also added pattern entries for functionName and region in schema.json so the constraints hold upstream as well.

npm run test:node (170 specs, 0 failures), npm run test:node-esm, ng lint, and a tsc -p tsconfig.build.json --noEmit are all clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bump: patch comp: schematics ng add / deploy schematics (src/schematics). type: bug Defect: expected behavior doesn't happen.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants